跳到主要内容

Java 并发编程-各种锁的概念

前置知识

参考资料 不可不说的Java“锁”事 参考资料 Java并发编程:volatile关键字解析

学习到 Java 多线程的那一部分发现线程同步引出了一个锁的概念,而这锁又有各种类型

这里借用美团的这张图归纳一下:

7f749fc8.png

共享锁和排他锁

共享锁和排他锁实际是一个概念的东西,但是两种不同表现形式

这两种锁的概念也比较多的出现在数据库的事务当中,所以下面一起讲了。

共享锁(ReadLock):也称读锁或 S锁。如果事务对数据 A 加上共享锁后,则其他事务只能对 A 再加共享锁,不能加排它锁。获准共享锁的事务只能读数据,不能修改数据。在 Java 中的 ReentrantReadWriteLock 也是如此。

Java 中共享锁是指该锁可被多个线程所持有。如果线程 T 对数据 A 加上共享锁后,则其他线程只能对 A 再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。

排它锁(WriteLock):也称独占锁、写锁或 X锁。如果事务对数据 A 加上排它锁后,则其他事务不能再对 A 加任何类型的锁。获得排它锁的事务即能读数据又能修改数据。

Java 中排他锁是指该锁一次只能被一个线程所持有。如果线程 T 对数据 A 加上排它锁后,则其他线程不能再对 A 加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK 中的 synchronized 和 JUC 中 Lock 的实现类就是互斥锁。


排它锁与共享锁也是通过 AQS 来实现的,通过实现不同的方法,来实现独享或者共享。

乐观锁与悲观锁的概念

参考资料 深入浅出Java多线程 CAS与原子操作 参考资料 不可不说的Java“锁”事

锁可以从不同的角度分类。其中,乐观锁和悲观锁是一种分类方式。

悲观锁:

悲观锁就是我们常说的锁。对于悲观锁来说,它总是认为每次访问共享资源时会发生冲突,所以必须对每次数据操作加上锁,以保证临界区的程序同一时间只能有一个线程在执行。Java中,synchronized 关键字和 Lock 的实现类都是悲观锁。

乐观锁:

乐观锁又称为“无锁”,顾名思义,它是乐观派。乐观锁总是假设对共享资源的访问没有冲突,线程可以不停地执行,无需加锁也无需等待。

乐观锁在 Java 中是通过使用无锁编程来实现,最常采用的是 CAS 算法,Java 原子类中的递增操作就通过 CAS 自旋实现的。

由于无锁操作中没有锁的存在,因此不可能出现死锁的情况,也就是说乐观锁天生免疫死锁。

总结:乐观锁多用于 “读多写少” 的环境,避免频繁加锁影响性能;而悲观锁多 “用于写多读少” 的环境,避免频繁失败和重试影响性能。

c8703cd9.png

公平锁和非公平锁

公平锁就是保障了多线程下各线程获取锁的顺序,先到的线程优先获取锁,而非公平锁则无法提供这个保障。

ReentrantLock、ReadWriteLock默认都是非公平模式,且一般情况非公平锁性能优于公平锁

公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。

  • 优点:所有的线程都能得到资源,不会饿死在队列中。
  • 缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。

非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。

  • 优点:可以减少 CPU 唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。
  • 缺点:这样可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁,导致饿死。

不过非公平锁还可能是根据某个线程的耗时长短来决定让哪个线程先执行(耗时短的优先)

自旋锁 和 自适应自旋锁

参考资料 轻松搞懂Java中的自旋锁

什么是自旋锁?

在程序中,如果存在着大量的互斥同步代码,当出现高并发的时候,系统内核态就需要不断的去 挂起线程和恢复线程,频繁的此类操作会对系统的并发性能有一定影响。

同时在程序的执行过程中锁定 “共享资源” 的时间片是极短的,如果仅仅是为了这点时间而去不断挂起、恢复线程的话,消耗的时间可能会更长,那就“捡了芝麻丢了西瓜” 了。

而在一个多核的机器中,多个线程是可以并行执行的。如果当后面请求锁的线程没拿到锁的时候,不挂起线程,而是继续占用处理器的执行时间,让当前线程执行一个忙循环(自旋操作),也就是不断在盯着持有锁的线程是否已经释放锁,这就是自旋锁了

image.png

什么是自适应自旋锁?

所谓的 “自适应” 意味着对于同一个锁对象,线程的自旋时间是根据上一个持有该锁的线程的自旋时间以及状态来确定的。

例如对于 A 锁对象来说,如果一个线程刚刚通过自旋获得到了锁,并且该线程也在运行中,那么 JVM 会认为此次自旋操作也是有很大的机会可以拿到锁,因此它会让自旋的时间相对延长。

但是如果对于 B 锁对象自旋操作很少成功的话,JVM 甚至可能直接忽略自旋操作。因此,自适应自旋锁是一个更加智能,对我们的业务性能更加友好的一个锁。

实现一个基于 CAS 的简易版自旋锁

public class SimpleSpinningLock {

/**
* 持有锁的线程,null表示锁未被线程持有
*/
private AtomicReference<Thread> ref = new AtomicReference<>();

public void lock(){
Thread currentThread = Thread.currentThread();
while(!ref.compareAndSet(null, currentThread)){
// 当 ref 为 null 的时候 compareAndSet 返回 true,反之为 false
// 通过循环不断的自旋判断锁是否被其他线程持有
}
}

public void unLock() {
Thread cur = Thread.currentThread();
if(ref.get() != cur){
// exception ...
}
ref.set(null);
}
}

使用这个自旋锁

public class TestLock {

static int count = 0;

public static void main(String[] args) throws InterruptedException {
// 使用线程池
ExecutorService executorService = Executors.newFixedThreadPool(100);
CountDownLatch countDownLatch = new CountDownLatch(100);

SimpleSpinningLock simpleSpinningLock = new SimpleSpinningLock();

for (int i = 0 ; i < 100 ; i++){
executorService.execute(() -> {
simpleSpinningLock.lock();
++count;
simpleSpinningLock.unLock();
countDownLatch.countDown();
});

}
countDownLatch.await();
System.out.println(count);
}
}

// 多次执行输出均为:100 ,实现了锁的基本功能

通过上面的代码可以看出,自旋就是在循环判断条件是否满足,那么会有什么问题吗?

如果锁被占用很长时间的话,自旋的线程等待的时间也会变长,白白浪费掉处理器资源。因此在 JDK 中,自旋操作默认 10 次,可以通过参数 -XX:PreBlockSpin 来设置,当超过来此参数的值,则会使用传统的线程挂起方式来等待锁释放。所以这种时候可以使用自适应自旋锁来自动解决这种问题

可重入锁和非可重入锁

参考资料 不可不说的Java“锁”事

实际上可重入锁又称递归锁,非可重入锁又称自旋锁

重入锁:重入锁允许锁和多个方法绑定,必须把这些绑定的方法全部执行完成才会释放锁

ReentrantLock 和 synchronized 都是重入锁

例如当一个线程获取了 A锁以后,若后续方法运行被 A锁锁住的话,当前线程也是可以直接进入的。

public class Demo {
private Lock lockA;

public Demo(Lock Lock) {
this.lockA = lock;
}

public void methodA() {
lockA.lock();
methodB();
lockA.unlock();
}

public void methodB() {
lockA.lock();
// do something
lockA.unlock();
}
}

当运行 methodA() 的时候,线程获取了 lockA,然后调用 methodB() 的时候发现也需要 lockA,由于这是一个可重入锁,所以当前线程也是可以直接进入的。在 Java 中,synchronized 跟 ReentrantLock 都是可重入锁。

不可重入锁:不可重入锁只允许锁和一个方法绑定,如果一个锁绑定了多个方法会导致死锁

以上面的代码实例来说明,就是 methodA 进入 methodB 的时候不能直接获取锁,必须先调用 unLock 释放锁才能执行下去,如果出现上面那种内部调用其它占用这个资源的锁的情况就会出现死锁

非可重入锁 NonReentrantLock

自旋锁就是比较经典的不可重入锁

public class SpinLock {

private AtomicReference<Thread> sign =new AtomicReference<>();

public void lock(){
Thread current = Thread.currentThread();
while(!sign.compareAndSet(null, current)){
}
}

public void unlock (){
Thread current = Thread.currentThread();
sign.compareAndSet(current, null);
}
}

为什么非可重入锁在重复调用同步资源时会出现死锁?

通过重入锁 ReentrantLock 以及非可重入锁 NonReentrantLock 的源码来对比分析一下为什么非可重入锁在重复调用同步资源时会出现死锁。

首先 ReentrantLock 和 NonReentrantLock 都继承父类 AQS,其父类 AQS 中维护了一个同步状态 status 来计数重入次数,status 初始值为 0。

当线程尝试获取锁时

1、可重入锁先尝试获取并更新 status 值,如果 status == 0 表示没有其他线程在执行同步代码,则把 status 置为 1,当前线程开始执行。如果 status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行 status + 1,且当前线程可以再次获取锁。

2、而非可重入锁是直接去获取并尝试更新当前 status 的值,如果 status != 0 的话会导致其获取锁失败,当前线程阻塞。

释放锁时

1、可重入锁同样先获取当前 status 的值,在当前线程是持有锁的线程的前提下。如果 status - 1 == 0,则表示当前线程所有重复获取锁的操作都已经执行完毕,然后该线程才会真正释放锁。

2、而非可重入锁则是在确定当前线程是持有锁的线程之后,直接将 status 置为 0,将锁释放。

32536e7a.png

死锁与预防

参考资料 廖雪峰的官方网站 死锁

Java 的线程锁是可重入的锁,即获取了这个锁后,再去调用同样需要这个锁的其它方法依旧可以

public class Counter {
private int count = 0;

public synchronized void add(int n) {
if (n < 0) {
dec(-n);
} else {
count += n;
}
}

public synchronized void dec(int n) {
count += n;
}
}

例如上面的 add() 方法,它本身已经带锁了,内部调用了另一个需要锁的方法 dec() 而这种能被同一个线程反复获取的锁,就叫做可重入锁

一个线程可以获取一个锁后,再继续获取另一个锁。例如:

public void add(int m) {
synchronized(lockA) { // 获得lockA的锁
this.value += m;
synchronized(lockB) { // 获得lockB的锁
this.another += m;
} // 释放lockB的锁
} // 释放lockA的锁
}

public void dec(int m) {
synchronized(lockB) { // 获得lockB的锁
this.another -= m;
synchronized(lockA) { // 获得lockA的锁
this.value -= m;
} // 释放lockA的锁
} // 释放lockB的锁
}

在获取多个锁的时候,不同线程获取多个不同对象的锁可能导致死锁。对于上述代码,线程1和线程2如果分别执行 add()dec() 方法时:

线程1:进入 add(),获得 lockA; 线程2:进入 dec(),获得 lockB。

随后:

线程1:准备获得 lockB,失败,等待中; 线程2:准备获得 lockA,失败,等待中。

此时,两个线程各自持有不同的锁,然后各自试图获取对方手里的锁,造成了双方无限等待下去,这就是死锁。

死锁发生后,没有任何机制能解除死锁,只能强制结束JVM进程。

因此,在编写多线程应用时,要特别注意防止死锁。因为死锁一旦形成,就只能强制结束进程。

应该如何避免死锁呢?答案是:线程获取锁的顺序要一致。即严格按照先获取 lockA,再获取 lockB 的顺序,改写 dec() 方法如下:

public void dec(int m) {
synchronized(lockA) { // 获得lockA的锁
this.value -= m;
synchronized(lockB) { // 获得lockB的锁
this.another -= m;
} // 释放lockB的锁
} // 释放lockA的锁
}

活锁

多线程中出现了相互谦让,都主动将资源释放给别的线程使用,这样资源在多个线程之间跳动而又得不到执行,形成活锁。

活锁恰恰与死锁相反,死锁是大家都拿不到资源都占用着对方的资源,而活锁是拿到资源却又相互释放不执行。当多线程中出现了相互谦让,都主动将资源释放给别的线程使用,这样这个资源在多个线程之间跳动而又得不到执行,这就是活锁。

不过活锁可能被解开,死锁则不能,下面是两种常见的场景以及解决办法

1、消息重试。当某个消息处理失败的时候,一直重试,但重试由于某种原因,比如消息格式不对,导致解析失败,而它又被重试

这种时候一般是将不可修复的错误不要重试,或者是重试次数限定

2、相互协作的线程彼此响应从而修改自己状态,导致无法执行下去。比如两个很有礼貌的人在同一条路上相遇,彼此给对方让路,但是又在同一条路上遇到了。互相之间反复的避让下去

这种时候可以选择一个随机退让,使得具备一定的随机性

线程饥饿

一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行的状态。多线程中优先级高的会优先执行,并且抢占优先级低的资源,导致优先级低的线程无法得到执行。

多线程执行中有线程优先级这个东西,优先级高的线程能够插队并优先执行,这样如果优先级高的线程一直抢占优先级低线程的资源,导致低优先级线程无法得到执行,这就是饥饿。

还有一种饥饿的情况,一个线程一直占着一个资源不放而导致其他线程得不到执行,与死锁不同的是饥饿在以后一段时间内还是能够得到执行的,如那个占用资源的线程结束了并释放了资源。

分布式锁

上面聊的这些锁,都是在单个程序上面的不同线程之间来实现的,那么当 不同程序 需要去竞争同一块资源的时候,这就需要分布式锁了,可以通过 redis、zookeeper 等中间件来实现分布式锁。